Repository navigation
fix(react-native): capture fatal React render errors - #5293
Conversation
Wrap ExceptionsManager.handleException so React 19 root onUncaughtError fatals reach the uncaught-exception fatal path.
Coherence4 spec problems · 0 failing chokepoints · 2 spec gaps · 0 guard failures 44 components, 7 bullets (5 invariants, 2 requirements), 4 problems Spec problems (spec --check)
Chokepoints: none declared. Spec gaps: 2 entrances with outside or unknown trust and no traced control, and 10 more held by the adoption baseline
Close one with |
…s until forwarded
|
This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the |
…wards it later Forwarded fatal Error objects pass straight through, so a previous handler that reports to React Native after it returns (Sentry) no longer causes a second capture.
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 9df50c6: 1 Must fix.
This is review round 3 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
React Native 0.79 and older have no web implementation of the modules it imports, so Expo web bundling failed.
…o fix/rn-render-error-capture
Problem
React render errors that crash the app no longer reach error tracking on React Native 0.78 and newer.
React 19 (RN 0.78+) reports an uncaught render error through the root
onUncaughtErrorcallback, which callsExceptionsManager.handleException(error, true)directly. It never reachesErrorUtils, the only hookposthog-react-nativeuses, so the SDK's fatal capture never runs. On React 18 (RN < 0.78) React rethrew render errors toErrorUtils, so they were captured.Reproduced on device, RN 0.86.2 new architecture, Release build:
$exceptionis sent.RCTFatalExceptionand the app aborts. The plugin drops that native report as a duplicate of a JS capture that never happened, so no$exceptionis sent.A customer reported this on iOS (RN 0.88-rc, Expo 58, new architecture). Related: #5271 makes iOS keep the native report when JS did not capture the crash.
Changes
ExceptionsManager.handleException(optional deep import ofreact-native/Libraries/Core/ExceptionsManager, feature-detected) so fatal calls go through the same tracker as theErrorUtilshandler. Render fatals then reachhandleFatalException, with the same durable native capture and persistence wait as any other fatal. The mechanism staysonuncaughtexception.ErrorUtilshook stays the primary path. If the module is missing or can't be wrapped, only the render path is skipped.handleExceptionis not captured again. Fatals still being persisted are tracked per error, and a duplicate report of one waits until the original has been forwarded. A forwarded Error is not captured again when the handler PostHog wraps reports it to React Native later (Sentry flushes before calling the handler it replaced).OptionalReactNativeExceptionsManager.web.ts, which imports nothing, so React Native'sExceptionsManagerstays out of the web dependency graph. RN 0.79 and older have no web implementation ofLibraries/Utilities/Platform, which it imports.errorTracking.autocapture.uncaughtExceptions.Rejected alternatives:
unstable_setExceptionDecorator: only runs insidereportException, which is skipped when native handles the exception, and it holds a single slot.RN$handleException: a read-only global, andhandleExceptioncalls it anyway.console.error: RN logs the error there before reporting it, but it can't tell a fatal render error from one an error boundary caught.Maintenance risk: if RN moves or stops exporting
Libraries/Core/ExceptionsManager, render-crash capture silently stops; handler fatals are unaffected. The module has lived at that path from RN 0.70 through today's nightly (0.89), and RN 0.87 still exports./Libraries/*after narrowing itsexportsmap. A scheduled contract check againstreact-native@latestand@nightlyis a possible follow-up.Known gap: on RN 0.78 only, with Metro's non-default
experimentalImportSupport: trueplusinlineRequires: false,ErrorHandlerscaches its namedhandleExceptionimport at startup, so render crashes bypass the wrap there. RN 0.79+ callsExceptionsManager.handleExceptionthrough the module object and is covered. Handler fatals are captured in every setup.Composing with #5271
Both hooks feed one tracker, which calls
ErrorTracking.onUncaughtException, where #5271 sets its marker. Whichever merges second needs at most a trivial rebase. With today's plugin, iOS sends one event (JS) because the native report is still dropped unconditionally.Test plan
posthog-react-nativevitest (77 files, 1347 tests, after merging main), lint, format and typecheck pass. New:test/render-error-capture.spec.tsandtest/optional-exceptions-manager.spec.ts. These cover fatal vs non-fatal, dedup, a missing or throwing module, autocapture off, Metro marking the deep import optional, both module shapes, and nothing loading on web.$exceptionevents per crash; same-uuid pairs at ingestion are counted once.genericerror, no fatal@sentry/react-native8.32.0 set up before PostHogErrorUtilsas a fatal, and Sentry's JS handler runs withenableNative: false.expo export --platform web, dev and production, passes on Expo 53 (RN 0.79.6) and Expo 57 (RN 0.86.2) with noExceptionsManagerin the bundle; the Expo 53 Android export still bundles it.useAlwaysAvailableJSErrorHandlingflag on, and Sentry set up after PostHog (that order forwards through RN's default handler synchronously, so the duplicate is skipped).Release info Sub-libraries affected
Libraries affected
Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
examples/example-expo-57.handleExceptionwrap, keepingErrorUtilsas the primary path so a broken deep import can't cost handler fatals. One independent review pass found no blocking issues; it surfaced a dedup memory leak, fixed in the second commit.__DEV__, LogBox's own internal failures can now be captured as fatals. Nothing logs when the deep import is unavailable.🤖 Generated with Claude Code